Published on

Web Audio API 실시간 DSP 컨트롤러 개발

#실전 예제

지금까지 배운 개념들을 실제로 구현해보겠습니다. 아래는 Web Audio API를 사용한 실시간 DSP 컨트롤 인터페이스 예시입니다.

Web Audio API DSP 실시간 톤 컨트롤 인터페이스 - 오실레이터 주파수, 로우패스 필터, Q값, 볼륨을 실시간으로 조절할 수 있는 웹 기반 신디사이저

위 인터페이스와 같은 실시간 오디오 컨트롤러를 단계별로 구현해보겠습니다.

#상세 주석 및 개념 설명

아래 문서는 HTML/JS 예제에 상세한 주석을 추가하고, 코드에서 사용된 Web Audio API의 핵심 개념실무 팁 / 개선 아이디어 / Unity 매핑까지 정리한 학습용 문서입니다.


#목차

  1. 주석 처리된 코드 (원본에 상세 주석 추가)

  2. 핵심 개념 해설

    • AudioContext와 오디오 그래프
    • 주요 노드(오실레이터, 필터, 게인 등)
    • 파라미터 자동화와 Ramp 방식 (exponential vs linear)
    • 필터의 Q(공명) 이해
  3. 실무 팁 & 브라우저 주의사항

  4. Unity 오디오 엔진과의 개념 매핑

  5. 코드 개선/확장 아이디어

  6. 간단한 실습 과제


#1) 주석 처리된 코드

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>실시간 톤 컨트롤 - 첫 번째 DSP</title>
    <style>
      /* (생략) 스타일은 UI 전용 — 오디오 동작과 직접 관련 없음 */
      body {
        font-family: Arial, sans-serif;
        max-width: 600px;
        margin: 50px auto;
        padding: 20px;
        background: #f0f0f0;
      }
      .container {
        background: white;
        padding: 30px;
        border-radius: 10px;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
      }
      /* 나머지 CSS 생략... */
    </style>
  </head>
  <body>
    <div class="container">
      <h1>🎵 첫 번째 DSP: 실시간 톤 컨트롤</h1>
      <p>간단한 사인파에 실시간으로 필터를 적용해보세요!</p>

      <div class="control-group">
        <button id="startBtn" class="start-btn">🎵 소리 시작</button>
        <button id="stopBtn" class="stop-btn" disabled>⏹ 정지</button>
      </div>

      <!-- 슬라이더들: UI 요소들 (JS에서 값 읽어서 오디오 파라미터로 전달) -->
      <div class="control-group">
        <label for="frequency">오실레이터 주파수 (Hz)</label>
        <input type="range" id="frequency" min="100" max="2000" value="440" />
        <div class="value-display" id="freqValue">440 Hz</div>
      </div>

      <div class="control-group">
        <label for="cutoff">로우패스 필터 컷오프 (Hz)</label>
        <input type="range" id="cutoff" min="100" max="8000" value="2000" />
        <div class="value-display" id="cutoffValue">2000 Hz</div>
      </div>

      <div class="control-group">
        <label for="resonance">필터 공명 (Q값)</label>
        <input type="range" id="resonance" min="0.1" max="20" step="0.1" value="1" />
        <div class="value-display" id="resonanceValue">1.0</div>
      </div>

      <div class="control-group">
        <label for="volume">볼륨</label>
        <input type="range" id="volume" min="0" max="1" step="0.01" value="0.3" />
        <div class="value-display" id="volumeValue">30%</div>
      </div>

      <div class="info">
        <h3>💡 학습 포인트</h3>
        <ul>
          <li>
            <strong>Web Audio API</strong>: AudioContext, OscillatorNode, BiquadFilterNode 사용
          </li>
          <li><strong>실시간 제어</strong>: 파라미터를 실시간으로 변경하는 방법</li>
          <li><strong>오디오 그래프</strong>: 노드들을 연결해서 신호 흐름 만들기</li>
          <li><strong>필터 효과</strong>: 컷오프 주파수와 Q값이 소리에 미치는 영향</li>
        </ul>
      </div>
    </div>

    <script>
      // 전역 변수: 오디오 객체들을 저장
      let audioContext // AudioContext 인스턴스 (오디오 그래프의 루트)
      let oscillator // OscillatorNode: 소리를 생성하는 노드
      let filter // BiquadFilterNode: 로우패스 필터
      let gainNode // GainNode: 출력 볼륨 제어
      let isPlaying = false // 현재 소리 재생 여부

      // DOM 요소들 (슬라이더, 버튼)
      const startBtn = document.getElementById('startBtn')
      const stopBtn = document.getElementById('stopBtn')
      const frequencySlider = document.getElementById('frequency')
      const cutoffSlider = document.getElementById('cutoff')
      const resonanceSlider = document.getElementById('resonance')
      const volumeSlider = document.getElementById('volume')

      // 값 표시 요소들 (UI에 실시간 값 보여주기)
      const freqValue = document.getElementById('freqValue')
      const cutoffValue = document.getElementById('cutoffValue')
      const resonanceValue = document.getElementById('resonanceValue')
      const volumeValue = document.getElementById('volumeValue')

      // ---------- 오디오 시작 처리 ----------
      startBtn.addEventListener('click', async () => {
        try {
          // AudioContext 생성: 브라우저마다 이름이 다를 수 있어 webkit 접두사 대응
          audioContext = new (window.AudioContext || window.webkitAudioContext)()

          // (팁) 브라우저는 사용자 제스처가 있어야 오디오가 재생되는 경우가 있음.
          // 만약 context.state가 'suspended'라면 resume()을 호출해야 소리가 납니다.
          // 예: await audioContext.resume();

          // 오실레이터 생성: 톱니파(sawtooth)를 사용 — 풍부한 고조파가 있어서 필터 효과가 잘 들립니다.
          oscillator = audioContext.createOscillator()
          oscillator.type = 'sawtooth'
          oscillator.frequency.setValueAtTime(440, audioContext.currentTime) // 기본 A4

          // 로우패스 필터 생성: 컷오프와 Q(공명)를 설정
          filter = audioContext.createBiquadFilter()
          filter.type = 'lowpass'
          filter.frequency.setValueAtTime(2000, audioContext.currentTime)
          filter.Q.setValueAtTime(1, audioContext.currentTime)

          // 게인 노드(최종 볼륨 제어)
          gainNode = audioContext.createGain()
          gainNode.gain.setValueAtTime(0.3, audioContext.currentTime) // 30% 볼륨

          // 오디오 그래프 연결: Oscillator -> Filter -> Gain -> Destination
          oscillator.connect(filter)
          filter.connect(gainNode)
          gainNode.connect(audioContext.destination)

          // 오실레이터 시작. 주의: OscillatorNode는 한 번 stop()하면 재사용 불가 — 새로 생성해야 함.
          oscillator.start()

          isPlaying = true
          startBtn.disabled = true
          stopBtn.disabled = false

          console.log('🎵 DSP 시스템 시작됨!')
          console.log('샘플 레이트:', audioContext.sampleRate, 'Hz')
        } catch (error) {
          alert('오디오 시작 실패: ' + error.message)
        }
      })

      // ---------- 오디오 정지 처리 ----------
      stopBtn.addEventListener('click', () => {
        if (oscillator) {
          // stop() 호출하면 oscillator는 끝남 — 재시작하려면 새 oscillator 생성 필요
          oscillator.stop()
          oscillator = null
        }
        if (audioContext) {
          // close()는 AudioContext를 종료하고 자원을 해제함.
          // 반복적으로 생성/종료하면 성능(지연, 리소스) 문제를 유발할 수 있음.
          audioContext.close()
          audioContext = null
        }

        isPlaying = false
        startBtn.disabled = false
        stopBtn.disabled = true

        console.log('🔇 DSP 시스템 정지됨')
      })

      // ---------- 실시간 파라미터 제어 (UI -> AudioParam) ----------
      frequencySlider.addEventListener('input', (e) => {
        const freq = e.target.value
        freqValue.textContent = freq + ' Hz'

        if (oscillator && isPlaying) {
          // 주파수를 부드럽게 변경: exponentialRampToValueAtTime 사용
          // 주의: exponential 램프는 0을 목표값으로 할 수 없음(양수만 허용).
          oscillator.frequency.exponentialRampToValueAtTime(freq, audioContext.currentTime + 0.1)
        }
      })

      cutoffSlider.addEventListener('input', (e) => {
        const cutoff = e.target.value
        cutoffValue.textContent = cutoff + ' Hz'

        if (filter && isPlaying) {
          // 필터 컷오프도 exponentialRamp로 소리가 자연스럽게 변하도록 함
          filter.frequency.exponentialRampToValueAtTime(cutoff, audioContext.currentTime + 0.1)
          console.log('필터 컷오프:', cutoff, 'Hz')
        }
      })

      resonanceSlider.addEventListener('input', (e) => {
        const q = e.target.value
        resonanceValue.textContent = q

        if (filter && isPlaying) {
          // Q값은 보통 setValueAtTime으로 즉시 적용해도 무방함
          filter.Q.setValueAtTime(q, audioContext.currentTime)
          console.log('필터 Q값:', q)
        }
      })

      volumeSlider.addEventListener('input', (e) => {
        const volume = e.target.value
        volumeValue.textContent = Math.round(volume * 100) + '%'

        if (gainNode && isPlaying) {
          // 볼륨은 linearRampToValueAtTime 으로 부드럽게 변화
          gainNode.gain.linearRampToValueAtTime(volume, audioContext.currentTime + 0.1)
        }
      })

      // 페이지 언로드 시 정리: 안전하게 오디오 종료
      window.addEventListener('beforeunload', () => {
        if (oscillator) oscillator.stop()
        if (audioContext) audioContext.close()
      })
    </script>
  </body>
</html>

위 코드의 주석은 코드 흐름 이해를 돕기 위해 자세히 달아두었습니다. 아래는 코드에서 사용된 개념들을 따로 풀이한 내용입니다.


#2) 핵심 개념 해설

#AudioContext와 오디오 그래프

  • AudioContext: Web Audio API의 최상위 객체로, 모든 오디오 노드(소리 생성기/처리기/출력)를 관리합니다. audioContext.destination은 시스템 스피커(또는 탑재된 오디오 출력)를 가리킵니다.
  • 오디오 그래프: 노드(예: OscillatorNode → BiquadFilterNode → GainNode)를 연결해 신호가 흐르는 경로를 구성합니다. 그래프는 신호의 생성 → 처리 → 출력의 흐름을 모델링합니다.

#주요 노드

  • OscillatorNode: 기본 파형(사인, 사각, 톱니, 삼각 등)을 생성합니다. 신디사이저의 '발성기' 역할.
  • BiquadFilterNode: 로우패스/하이패스/밴드패스 등 클래식 필터를 제공합니다. 컷오프 주파수(freq)와 Q(공명) 파라미터로 조정합니다.
  • GainNode: 신호의 크기(볼륨)를 조절합니다. 보통 최종 출력 전에 배치합니다.

#파라미터 자동화와 Ramp

  • AudioParam: (예: oscillator.frequency, filter.frequency, gainNode.gain)은 시간에 따른 값 변화를 스케줄링 할 수 있는 객체입니다.

  • 자동화 메서드 주요 3가지:

    • setValueAtTime(value, time): 특정 시간에 즉시 값 설정
    • linearRampToValueAtTime(value, endTime): 선형 보간으로 일정 시간 동안 값 변경
    • exponentialRampToValueAtTime(value, endTime): 지수 보간(자연스러운 피치/주파수 변화에 흔히 사용)
  • 주의: exponential ramp는 0이나 음수로 변화할 수 없습니다(수학적으로 불가). 목표값은 항상 양수여야 함.

#필터의 Q(공명)

  • Q값: 필터의 공명(대역폭)을 제어합니다. Q가 높을수록 컷오프 근처에 피크가 생겨 소리가 더 "날카롭게" 들립니다.
  • 일반적으로 Q가 너무 높으면 출력이 울리거나 불안정해질 수 있으므로 값 범위를 적절히 제한하는 것이 좋습니다.

#3) 실무 팁 & 브라우저 주의사항

  • 사용자 제스처 필요성: 많은 브라우저가 자동 재생을 막습니다. AudioContext는 생성 후 suspended 상태일 수 있으므로, 사용자 클릭 시 await audioContext.resume()를 호출해 확실히 재생 가능 상태로 만드세요.
  • OscillatorNode는 재사용 불가: .stop() 이후 재시작하려면 새 OscillatorNode를 생성해야 합니다.
  • AudioContext 재생성 비용: stop 시 audioContext.close()를 호출하면 리소스 해제는 되지만, 자주 생성/종료하면 성능에 영향을 줍니다. 가능하면 하나의 AudioContext를 유지하고 필요 시 suspend/resume을 쓰는 편이 낫습니다.
  • 클릭/팝 방지: 볼륨을 즉시 바꾸면 클릭음이 날 수 있음. 시작/정지/변화 시 linearRampToValueAtTime이나 setTargetAtTime으로 작은 페이드를 주면 클릭을 줄일 수 있습니다.
  • ScriptProcessorNode는 deprecated: 오래된 방법으로 AudioWorklet를 사용해 커스텀 DSP를 구현하세요. AudioWorklet은 오디오 스레드에서 실행되므로 지터가 적고 안정적입니다.
  • 샘플레이트와 지연(Latency): audioContext.sampleRate로 현재 컨텍스트의 샘플레이트를 확인하세요. 모바일/데스크탑/사운드카드에 따라 레이턴시가 다릅니다.

#4) Unity 오디오 엔진과의 개념 매핑

  • AudioContextUnity: Audio System / AudioListener (전역 루트)
  • OscillatorNodeUnity: 직접 생성 시 OnAudioFilterRead에서 합성하거나 AudioSource + AudioClip(PCM 데이터)로 사용
  • BiquadFilterNodeUnity: AudioMixer의 필터(또는 커스텀 DSP 플러그인)
  • GainNodeUnity: AudioSource.volume 또는 AudioMixerGroup의 볼륨
  • AudioWorklet (커스텀 DSP)Unity: C#의 OnAudioFilterRead 또는 네이티브 플러그인(C/C++)

요약: 개념(신호 생성 → 처리 → 믹스 → 출력)은 동일합니다. 다만 Unity는 네이티브 환경이라 더 낮은 레이턴시와 더 정교한 DSP가 가능하므로 Web Audio로 쌓은 개념이 Unity로의 이동을 상당히 수월하게 만들어 줍니다.


#5) 코드 개선/확장 아이디어 (실용적 제안)

  1. AudioContext 재사용: 시작할 때마다 새로 만들기보다 전역에서 하나만 생성하고 suspend()/resume()을 사용.
  2. 페이드 인/아웃(클릭 방지): 재생 시작 시 gainNode.gain.setValueAtTime(0, now); gainNode.gain.linearRampToValueAtTime(target, now+0.02); 처럼 아주 짧은 공격(attack)을 주기.
  3. 파라미터 안정화: 슬라이더가 너무 빠르게 움직이면 많은 스케줄이 쌓일 수 있음. cancelScheduledValuessetValueAtTime + setTargetAtTime 조합을 사용해 안정화.
  4. AudioWorklet 사용: 직접 필터나 신디사이저 알고리즘을 구현하려면 AudioWorklet을 쓰세요. WebAssembly로 구현하면 더 빠릅니다.
  5. MIDI/키보드 연동: 키보드 입력 또는 Web MIDI API를 이용해 노트 온/오프를 연결하면 신디사이저처럼 동작합니다.
  6. ADSR 엔벨로프 구현: GainNode를 이용해 Attack/Decay/Sustain/Release를 구현하면 자연스러운 음색 제어 가능.

#예: 클릭 방지용 간단 페이드 (코드 스니펫)

// 재생 시작 시
const now = audioContext.currentTime
gainNode.gain.setValueAtTime(0, now)
gainNode.gain.linearRampToValueAtTime(0.3, now + 0.02) // 20ms 페이드 인

// 정지 시 (짧은 페이드 아웃 후 stop)
const now2 = audioContext.currentTime
gainNode.gain.cancelScheduledValues(now2)
gainNode.gain.setValueAtTime(gainNode.gain.value, now2)
gainNode.gain.linearRampToValueAtTime(0.0, now2 + 0.02)
oscillator.stop(now2 + 0.03)

#6) 간단한 실습 과제 (학습용)

  1. ADSR 추가: 슬라이더 4개(Attack, Decay, Sustain, Release)를 추가하고, GainNode로 envelope를 적용해 보세요.
  2. 파형 선택: oscillator.type을 UI로 변경해 사인/톱니/사각 비교해보기.
  3. 커스텀 웨이브폼: setPeriodicWave로 하모닉을 직접 만들어 필터와의 상호작용 실험.
  4. AudioWorklet으로 간단한 이펙트 제작: 예를 들어 간단한 distortion 또는 custom filter를 AudioWorklet으로 구현.
  5. Unity로 클론: 위 기능을 Unity에서 OnAudioFilterRead와 AudioMixer로 다시 구현해 보세요. Web Audio에서 쌓은 개념이 큰 도움이 됩니다.

#🛠 AudioWorklet

AudioWorklet은 브라우저에서 실시간으로 오디오를 처리할 수 있는 사용자 정의 장치입니다.
기존 노드(GainNode, Filter 등) 대신 직접 알고리즘을 작성할 수 있어요.


#🏭 공장 비유

AudioContext = 공장 관리자 (전체 오디오 라인 관리)
AudioWorklet = 공장에서 새로 만든 맞춤형 기계 (특수 효과, 필터 등)

  • 기존 Web Audio API 노드(예: GainNode, BiquadFilterNode)는 미리 만들어진 장치
  • AudioWorklet은 직접 설계한 장치 → 특별한 소리 효과, 신디사이저, 분석 등 가능

#📌 특징

  1. 실시간 오디오 처리
    • 브라우저 메인 스레드와 분리되어 실행
    • UI가 느려지거나 멈추지 않음
  2. 커스텀 처리 가능
    • 자신만의 필터, 신디사이저, DSP 알고리즘 작성 가능
  3. 모듈 방식
    • JS 파일로 분리하여 재사용 가능

#🔧 사용 예시

#1️⃣ Worklet JS 파일 생성 (`processor.js`)

// AudioWorkletProcessor를 상속하여 나만의 오디오 처리기를 만듭니다.
class MyProcessor extends AudioWorkletProcessor {

    /**
     * process 메서드는 오디오가 처리될 때마다 자동으로 호출됩니다.
     * @param {Array} inputs - 입력 오디오 데이터 (배열 형태, 여러 채널 지원)
     * @param {Array} outputs - 출력 오디오 데이터 (배열 형태, 여러 채널 지원)
     * @param {Object} parameters - 외부에서 전달한 파라미터 (optional)
     * @returns {boolean} true를 반환하면 계속 처리, false면 종료
     */
    process(inputs, outputs, parameters) {

        // 첫 번째 입력과 출력을 가져옵니다.
        const input = inputs[0];   // 마이크나 오디오 소스 입력
        const output = outputs[0]; // 스피커 또는 다음 노드 출력

        // 입력 채널 개수만큼 반복
        for (let channel = 0; channel < input.length; channel++) {
            const inputChannel = input[channel];   // 현재 채널 입력 데이터
            const outputChannel = output[channel]; // 현재 채널 출력 데이터

            // 채널의 모든 샘플에 대해 반복
            for (let i = 0; i < inputChannel.length; i++) {
                // 단순 볼륨 조절 예제: 입력 값의 50%로 출력
                outputChannel[i] = inputChannel[i] * 0.5;
            }
        }

        // true 반환 → 오디오 처리를 계속 유지
        return true;
    }
}

// 오디오 컨텍스트가 사용할 수 있도록 등록
registerProcessor('my-processor', MyProcessor);

2️⃣ AudioContext에서 불러오기
async function init() {
    // 1️⃣ AudioContext 생성
    const audioContext = new AudioContext();

    // 2️⃣ Worklet 모듈 추가
    // processor.js 파일을 불러와 브라우저에서 사용할 수 있게 함
    await audioContext.audioWorklet.addModule('processor.js');

    // 3️⃣ Worklet 노드 생성
    // 'my-processor'는 processor.js에서 등록한 이름과 동일해야 함
    const workletNode = new AudioWorkletNode(audioContext, 'my-processor');

    // 4️⃣ 마이크 입력 가져오기
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    const source = audioContext.createMediaStreamSource(stream);

    // 5️⃣ 연결
    // 마이크 입력 → Worklet 처리기 → 스피커 출력
    source.connect(workletNode).connect(audioContext.destination);
}

// 초기화 함수 실행
init();

💡 설명

AudioWorkletProcessor

실시간 오디오를 처리하는 클래스

process 메서드에서 입력을 읽고 원하는 방식으로 출력

inputs, outputs

2차원 배열 구조: [채널][샘플]

여러 채널(스테레오 등)을 지원

WorkletNode 연결

일반 오디오 노드처럼 연결 가능

connect를 통해 다른 노드 또는 스피커로 연결

실시간 처리

브라우저 메인 스레드와 분리되어 UI 지연 없이 실행 가능